EnriqueMark
隨筆

rust 概念

2026-07 繁體中文

rust本身比較有意思的地方是,他是一個強型別-記憶體安全的語言,所以說很多東西都會規定的很死,通過一定的動態犧牲,帶來了較大的安全性和穩健性。

比如變數上,rust這邊的變數預設就是常量,需要 let mut 額外宣告的才是可變的(mutable)。而還有關於記憶體的明確操作,關於字串這裡就能看出來:

// 这是一个指向字符串视图的指针,是纯粹只读的
let s1: &str = "hello";
// 这才是实际的字符串对象,可以修改
let s2: String = String::from("hello");

而rust最關鍵的概念還在於「所有權」這個核心,所有值都有一個所有者,而變數則在賦值的那一刻拿到這個所有權。就所有權來說,這裡分為兩個情況:所有者轉移自身的所有權,背後發生的操作是 move 代表著的就是所有者的轉換;其次則是 copy ,這裡沒有發生所有權轉移,而是一模一樣的直接複製,原值也仍然有效,這裡同時存在兩個值和兩個所有者。從所有權的角度看,copy 是直接建立了一個新的值(連帶它的所有者),而舊的仍然存在。move 則不然,舊的所有者會失去所有權。對於有些物件來說,想要達到 copy 的效果必須顯示的去 clone ,不然預設就是轉移,舊變數在賦值給新變數的那一刻,他的所有權就會轉移給新的變數。

理清了所有權之後,就能更好地理解它的相關延展,比如拿走和借用:

fn consume(s: String) {
    println!("{}", s);
}

fn main() {
    let a = String::from("hello");
    consume(a);
    // println!("{}", a); // error:a 已经被 move
}

這裡展現了一個初看會讓人困惑的操作,那就是在ts裡,物件或者說值(本質也是物件)本身是可以被所有人重複使用的。但是在rust這裡,任何物件都必須先明確所有權,只有具備許可權者才能進行其範圍內的操作。s: String 在這裡意味著所有權轉移,也即s成為了唯一可操作此物件的所有者,a因為不具備任何許可權,因此它的任何操作也是失效的,除非它進行一次所有權的轉移操作 let a = s,接過s的所有權,其後續操作才能成為合法操作。

但這裡有一個部分會讓人非常難以理解,那就是乍一看,這裡似乎把一個公用的物件給直接私有化了,那豈不是我其他函式都用不了 String 物件了?但其實不是這樣,所有權有「作用域」的限制,一旦離開s的作用域(比如這裡就是函式內部),那麼它的所有權就會被 drop,新作用域中,a需要重新獲得所有權,才能使用——比如 a: String,它之所以操作失敗不是因為s私有化了這個所有權,擠佔了其他人使用,而是因為a自身沒有在用之前先取得所有權。

隨著 rust在ai開發中的優勢 繼續說,有一個容易讓人混淆的部分,就是雙冒號(::)和點(.)的區別。這個概念在其他語言下是混用的,但在rust的哲學之下就需要明確區分。前者意味著名稱空間導航,即針對於「結構」的索引,即「不需要具體值就存在的東西」,一般的抽象藍圖,除非在藍圖裡就預定義好常量的值,否則預設取到的就是成員本身;而後者是對具體「值」的呼叫,即「必須有具體值才存在的東西」,對藍圖的實際實現。

struct Point {
    x: i32,
    y: i32,
}

impl Point {
	// 关联函数(没有 self),这个类比的话很像class中的静态方法,可以在非实例化的情况下直接使用
    fn new(x: i32, y: i32) -> Point {    
        Point { x, y }
    }
    // 方法(有 self),这意味着必须先有了值才能去使用这个方法
    fn distance(&self) -> f64 {          
        ((self.x * self.x + self.y * self.y) as f64).sqrt()
    }
}

let p = Point::new(3, 5);   // :: 从类型 Point 取关联函数 new  ✅
p.x                          // .  从实例 p 取字段            ✅
p.distance()                 // .  在实例 p 上调方法          ✅
Point::x                     // ❌ 非法,x 是字段不是关联项

這個地方需要明確防止一個誤會,那就是雙冒號取的是型別成員,而不是型別內的欄位,因為只有「成員」才有路徑身份,而「欄位」是沒有的。

Point::new(3, 5)        // new 是关联函数,有路径身份 ✅
Point::ORIGIN_Y         // ORIGIN_Y 是关联常量,有路径身份 ✅
Point::y                // y 是字段,没有路径身份,处处非法 ❌

而這裡就要順帶引出一個概念,impl乍看之下很像是類,但實際上並不是。核心點就在於rust從根本上ban了繼承,因此這個地方的impl只是一個「給型別附加方法」的結構體,而 &self從字面意思上就指向這個型別Point自身。

因此,上面說的像靜態方法的「關聯函式」,乍一看很像是類的建構函式,畢竟new的時候返回了一個Point例項。但實際上並非如此。跟一般類的建構函式規定只能有一個不同,fn new在整個 impl 裡面,跟其他方法相比是完全平等的。這就意味著寫幾個都行,也意味著它根本不是一個「建構函式」,只是普通的靜態(鑑於rust裡沒有類這一說,因此這裡是類比意義上)工廠方法。

而關於原先OOP的其他概念,rust仍舊有所留存,比如封裝,就通過 pub 這個保留關鍵詞來指定暴露想要的介面,而相較於其它語言需要明確宣告 private,它在型別內的屬性是預設私有的。

mod geometry {
    pub struct Point {
        x: i32,        // 私有字段
        pub y: i32,    // 公开字段
    }
}

// 在模块外面:
let p = geometry::Point { ... };
p.y    // ✅ 能访问,因为 y 是 pub
p.x    // ❌ 编译错误:x 是私有的,模块外碰不到

trait 則是一個比較重要的概念,直接等價於其他語言中的 interface ,即介面(契約)的概念。本質上是對行為結構的抽象定義,也是rust中DI能順利施行的重要因素——通過介面定義好行為結構後,就可以使後續的組合面向介面而非具體實現程式設計。但rust的trait本身可以帶預設實現(看上去有點像類了,但這個顯然跟剛才的一樣,是預定義好的方法)。

trait Animal {
    fn speak(&self) -> String;
    fn greet(&self) -> String {           // 默认实现
        format!("我说:{}", self.speak())  // 复用 speak,谁实现 Animal 都白捡这个 greet
    }
}

而這玩意兒最強也最獨特的地方似乎給標準庫,或者說任何其他已有的型別加實現。

trait Describe {
    fn describe(&self) -> String;
}
impl Describe for i32 {                            // 给标准库的 i32 加行为!
    fn describe(&self) -> String { format!("我是数字 {}", self) }
}

42.describe()    // ✅ "我是数字 42"

其次則是可以明確的區分編譯時和執行時,這裡稍微有點複雜,涉及到不同語言彼此之間對於多型的派發策略,即到底是編譯期間就直接定死,還是執行時去查一個虛擬表來確定此泛型指向什麼具體的函式——大部分語言對於介面上的此類泛型傳入處理,預設都是動態派發的,還沒法選,因此有一個查表開銷和執行時的混合問題;而rust在這裡明確的通過 Tdyn 的區分,或者說,通過設計了一套「泛型 和 trait object」的兩條路,將兩者顯式化了。這本身就是rust的設計哲學的再一次體現,那就是「將隱含的東西顯現化」,無論是通過所有權概念體現的記憶體控制,還是顯式邊界,此處只是又一次貫徹。

// 静态派发(编译期多态):泛型 + trait 约束
// 此时的 T 最终是永久单态化的,即编译时定了什么,最终就是什么,不然报错,不放到运行时才决定
fn make_speak<T: Animal>(a: T) -> String {
    a.speak()                    // T 是任何实现了 Animal 的类型,编译期定死,零开销
}

// 动态派发(运行时多态):trait object,&代表引用,一个指向a的指针的指针
// 这里会根据运行时决定,此处的 dyn Animal 取决于运行时到底给入什么
fn make_speak_dyn(a: &dyn Animal) -> String {
    a.speak()                    // 运行时才知道具体是 Dog 还是 Cat,查 vtable
}

// 注意 vec![...] 只是个演示用的list构造,模拟的是运行时给入多个类型的情况
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &zoo {
    println!("{}", animal.speak());   // 同一个循环,Dog 喵 Cat 各说各的 —— 经典多态
}

然後另一個則是trait最不同於interface的點,那就是它可以寫成空的。

// Send / Sync 大致长这样(简化):
trait Send {}     // 空的!没有任何方法
trait Sync {}

這裡的 Send 和 Sync ,或者說本質上這種空的 trait 是一個會自動連鎖傳播的標記,可以在編譯期就給所屬行為打上一個標籤。這種標籤使得編寫者可以顯式控制和宣告特定操作,從而在該操作不該發生的地方在編譯期就攔截。比如Rc標準庫,他的用途是建立索引,讓一份資料被多個所有者共享,而其內部會維護一個所有者計數器——對這東西的修改會存在併發競爭問題。那麼此時 Send 標籤就能發揮作用。

Rc 的編寫者只需要顯式宣告此處是 !Send 的,而跨執行緒操作庫的 thread::spawn 再予以配合,要求只有 Send 標籤的操作才能夠通過。那麼通過這種跨方法約定,就可以直接將危險操作攔截在編譯器,而不至於到了執行時才暴露。這個操作往廣了說可以非常靈活,比如併發,複製,只要能加標籤同時要在另一個地方設定門檻時,都可以用類似機制。從編譯上就讓不符合門檻的操作直接報錯,絕對不讓你到執行時才出問題。只要一方設定了標籤,那麼實現了這個介面的子物件都會自動被傳播到。這個特性是 Send / Sync 特有的,叫做auto trait,會自動傳播到所有消費方。

上面那個自動傳播機制是 rust 的特色,其他語言沒有,但除此之外的其他自定義空殼標籤就沒有這個傳播機制了。比如我寫了個表現,但是後來忘了設定查票者或者給下游蓋章,那這一套就無效了。換言之,這套標籤機制的有效性取決於編寫者的設計和對自身程式碼的邊界把控能力。如果毫無設計,該出的問題還是會出現。但如果這樣,rust 最額外的優勢在哪裡呢?核心點在於「編譯期」保證。rust 的空 trait 一旦加上,後續沒有那就是沒有,直接報錯,但類似的比如 java,就得到執行期才能暴露問題。

// ① 定义标签(空壳)
trait A {}

// ② 给某个类型盖章
struct Dog;
impl A for Dog {}          // 为 Dog 装上 A

struct Cat;
// Cat 不盖章

// ③ 下游:在函数签名里"查票"—— <T: A> 就是那道卡
fn need_ticket<T: A>(x: T) {
    // 能进到这个函数体里的 x,类型必然带 A。编译器已经替你保证了。
}

// ④ 消费方尝试
need_ticket(Dog);   // ✅ Dog 有 A,过
need_ticket(Cat);   // ❌ Cat 没 A,编译报错:the trait `A` is not implemented for `Cat`

fn need_ticket<T: A>(x: T) {}              // 写法1:泛型参数上直接标
fn need_ticket<T>(x: T) where T: A {}      // 写法2:where 子句,复杂约束时更清爽
fn need_ticket(x: impl A) {}               // 写法3:impl Trait 语法,最简洁

rust 這裡比ts的優勢在於,這個標籤沒辦法被下游輕易的用 as 斷言蓋過去。如果非得寫,必須顯式宣告,把該物件的型別明確指定到標籤上,代價很明確,這意味著下游全部都知道你加了這個標籤(因為是空的,所以不影響行為,但會明確打上章,而很難暗度陳倉);而 as 斷言就只是一句臨時斷言,並未根本上改變原先物件的型別。其次則是,在 rust 這裡 A {}的主語是A,而ts那裡則是{}。ts的型別本身對結構體負責,換言之,空的{}結構可以對應到任何一個物件,而不是標籤的語義意涵。